feat(gateway,ports,http,cli): gateway skeleton — config.mode, uplink, worker views, worker.* (ADR 0005, #117) - #129
Merged
V3RON merged 30 commits intoSep 9, 2026
Conversation
V3RON
force-pushed
the
claude/adr-0005-117-gateway-skeleton
branch
2 times, most recently
from
September 7, 2026 16:54
ad3fd2f to
9c5f97a
Compare
V3RON
force-pushed
the
claude/adr-0005-117-gateway-skeleton
branch
2 times, most recently
from
September 8, 2026 17:48
f9eb9cf to
f3d322b
Compare
V3RON
added this pull request to stack #123
September 9, 2026 16:16
…codes The contract half of ADR 0005 #117, plus the config keys the two modes read. Contract: - `status.get` gains `daemon.mode` (every daemon says which mode answered) and an additive, gateway-only `workers` array of worker views; its devices and leases gain an optional `workerId`, so a fleet's aggregate is the same shape a single host returns (§20). `leaseRecordSchema` itself is untouched -- `status.get` uses an extension of it, so `lease.renew`/`lease.list` keep their shapes. - `catalog.get`'s per-platform entry gains `modelWorkers`/`runtimeWorkers` annotations (§21), beside the flat lists rather than instead of them, so an existing renderer is unaffected. - `worker.list|drain|undrain|remove` (admin, §8/§23) and the worker view shape they return. A worker's own dispatcher deliberately declares no handler for these -- its handler map now excludes `GATEWAY_ONLY_OPERATIONS` -- so asking a worker for one answers `UNKNOWN_REQUEST` rather than a fabricated empty fleet. - Token role `worker` (§8/§25): a join token. The HTTP bearer adapter answers `403` for one on any `/v1` route -- the one role decision that belongs at the transport, because a join token authorizes a transport and no operation at all. - Error codes `UNSUPPORTED_IN_GATEWAY_MODE` (501), `WORKER_CONNECTED` (409), and `UNKNOWN_WORKER` (404). The third is not in the ADR's list; drain, undrain and remove all need an answer for an id the gateway has never seen, and inventing a silent success for it would be worse than a fourth row. Config: - `mode: "worker" | "gateway"`, default `worker` (§1). - `gateway.url`/`gateway.token`/`gateway.label` (worker side, §3) and `gateway.disconnectedRetentionMs` (gateway side, default 24h, §6). - A gateway defaults `http.enabled` to true and refuses an explicit `false` naming the key (§2): HTTP is how agents reach the fleet *and* what the worker uplink upgrades from, so a gateway without it is unreachable. A worker's HTTP gateway stays opt-in exactly as before. - Worker-only keys in a gateway's config warn and are ignored, per key the operator actually wrote (§2); `gateway.url`/`token`/`label` warn there too. - `gateway.url` is validated as a ws:// or wss:// URL at load, so a typo fails the start naming the key instead of surfacing as an endless reconnect loop. `DispatchSession`/`DispatchError`, and the new `ContractDispatcher` shape both dispatchers satisfy, move to `src/daemon/dispatch.ts` -- a leaf module with no `src/core` imports, so the gateway can implement the contract without importing the worker's engine through the back door. `dispatcher.ts` re-exports them, so no call site changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
…ther dispatcher ADR 0005 §4/§5/§33. Two halves of one link, behind a port: - `UplinkConnector` (worker side) dials `gateway.url` with the join token and the worker's instance id; `UplinkListenerFactory` (gateway side) accepts an authenticated uplink. Both traffic in `IpcConnection`, because what travels over the link is the existing daemon protocol -- the worker's own `DaemonServer` serves it, and the gateway drives it with the typed client. - `MemoryUplinkTransport` is the in-process adapter: it runs the same authentication and rejects with the same codes, so a test that exercises a revoked token exercises the real branch. - The WebSocket adapters live in `ports/uplink-websocket.ts`, the one module that imports `ws` -- kept out of the ports barrel so the CLI and the MCP server never load it. `ws` (pinned 8.18.3) is the one new runtime dependency: Node 22 has a WebSocket client and no server, and its client cannot set the request headers the join token and worker id travel in. The gateway's half is `noServer: true` and upgrades on the *existing* HTTP listener, so the fleet still has exactly one inbound port; authentication happens at upgrade, so an unrecognized peer gets a plain 401 and never reaches the daemon protocol. `GatewayUplink` is the worker's reconnect supervisor: dial on start, redial after any disconnect with exponential backoff, capped and jittered across the lower half of the window (a gateway restart drops every worker at once). A revoked token keeps retrying at the cap -- an operator may mint a new one at any moment, and a worker that gave up would need a restart nobody would think to perform -- but logs `rejected` rather than `unreachable`, so the log says which it is. `DaemonServer` gains two things: - `acceptUplink(connection)`: accepts a worker's outbound uplink as one more connection and grants that session `admin`. Not a hole in ADR 0003 §5 -- §5 forbids inferring authority from a transport the daemon *accepted*; here the daemon dialled out, to the URL in its own config, with the token from that same file, which the gateway verified before the connection existed. The trust runs from the worker's configuration, which is what §5 asks for. Role is per-connection state decided by how it was accepted; everything else -- range negotiation, role checks, ownership -- is unchanged. - An injected `dispatcher`, as a union with the worker engine options, so a gateway can serve the same transport with its own handlers (ADR 0005 §32) while a worker's options stay exhaustively checked at compile time. Owner- routed lease facts are inert in gateway mode: a worker's republished `lease.expired` names that worker's owner, not a gateway client's principal, so routing one would push another machine's fact at the wrong holder. #118 and #119 relay those with the gateway's own lease index. ADR 0003 §2's dispatch pipeline -- parse input, role check, authorize, park on readiness, call handler, parse output -- moves into `runDispatch` in `daemon/dispatch.ts` and is shared by both implementations. A gateway that checked roles slightly differently from a worker would be a second contract wearing the first one's name. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
`src/gateway/` -- the second implementation of the daemon contract (ADR 0005 §32), for a daemon that owns no devices and fronts the workers connected to it. - `WorkerLink` drives one uplink as the protocol *client*, with the same typed admin client a supervisor uses over a unix socket: `status.get`, `list.get`, `catalog.get` and `events.subscribe` on connect (§7), plus `config.get` for the worker's download policy, which #118's routing needs as an input (§13). It refreshes on every worker event about a lease or a device, coalescing a burst into one round trip plus one follow-up, and republishes every worker event on the gateway's own bus with `workerId` added (§22) -- name and emitting module unchanged, because the fact happened in that worker's lease engine and rewriting either would make the audit trail lie. - A worker whose `hello` finds no overlapping protocol range is `incompatible` in its view, carrying both ranges, and is asked nothing further (§31). The device records a worker reports are narrowed to the contract's status shape on the way in, so no driver-private `driverData` crosses the fleet. - `WorkerRegistry` owns the views and the facts about them: `worker.connected`, `worker.rejected` (incompatible, or an uplink turned away at the upgrade), `worker.disconnected`, `worker.removed`, `worker.drain-started`, `worker.drain-ended`. Retention keeps a disconnected view until its last known lease deadline has passed *and* `gateway.disconnectedRetentionMs` has elapsed -- a machine that vanished holding a live lease is exactly what an operator must still see. Drain is the one piece of persisted gateway state (Decision 3): a tiny owner-only `workers.json`, so a machine taken out of service stays out across its own reconnect and a gateway restart. - `GatewayDispatcher` answers the contract from those views through the shared `runDispatch` pipeline. `status.get` and `catalog.get` aggregate (§20/§21); `lease.list` and `list.get` report the fleet read-only, each row naming its worker -- the fleet made visible, which is this PR's point, and what the operator HTTP routes render. `nuke.run`, `cleanup.run`, `doctor.run` and `driver.passthrough` answer `UNSUPPORTED_IN_GATEWAY_MODE` permanently (§34); the lease *lifecycle* answers it until #118. The handler table is typed total over the contract, so a new operation is a compile error here until someone decides which of the three populations it belongs to. - `GatewayService` is the lifecycle: the uplink listener, one link per worker, and the slow tick that backstops event-driven refreshes and sweeps retired views. `boundary.test.ts` enforces ADR 0005 §33: nothing here imports `src/core`, `src/drivers`, or a frontend, and the single `src/daemon` import is the dispatch contract -- which the same test asserts is itself core-free, so the allowance cannot become a back door. Also settled with the ADR review (#127) and applied here: protocol range moves to `{min: 5, max: 5}` with no shim, so a pre-0005 worker is `incompatible` by range; `worker.drain`/`undrain` answer `UNKNOWN_WORKER` for an id with no view while `worker.remove` reports `{removed: false}`; `WORKER_UNREACHABLE` joins the closed error table for #118; `gateway.execTimeoutMs` (11 minutes, one more than the worker's authoritative ten) joins the config; a worker naming a gateway URL without a token, or the reverse, fails its start. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
…ateway mode
The frontends and the composition root, so the fleet is reachable.
HTTP (gateway only -- on a worker these paths are not routes at all, so a
`404` says "no such resource" rather than a `501` implying a fleet that is
switched off): `GET /v1/workers`, `POST/DELETE /v1/workers/{id}/drain`,
`DELETE /v1/workers/{id}`. Each is one dispatch; the admin role check stays in
the shared dispatcher, so an agent token gets its `403` from the same place a
socket client does. A `DispatchError`'s typed `details` now reach the response
body and the socket error frame -- `WORKER_CONNECTED`'s `workerId` and
`UNSUPPORTED_IN_GATEWAY_MODE`'s `operation` are contract (ADR 0003 §7), and a
client should branch on them rather than parse prose.
CLI: `simlock worker list|drain|undrain|remove`, `token create --role worker`,
and `simlock status` rendering the fleet -- the mode on the daemon line, one
line per worker, and `on <workerId>` against each device and lease when the
answer came from a gateway. A worker's own output is unchanged apart from the
mode.
`startDaemon` branches on `config.mode` before any device machinery is built:
- **gateway**: instance identity, token store, admin secret, the worker
registry (with its persisted drain set), `GatewayService`, and a
`DaemonServer` carrying `GatewayDispatcher` instead of an engine. Its HTTP
listener is unconditional, and the uplink upgrades on that same listener, so
the fleet has exactly one inbound port. Starting the uplink listener is the
gateway's "convergence": a listener that cannot start fails the start, while
`status.get` answers throughout (a fleet with no views yet is a true answer).
- **worker**: unchanged, plus one outbound `GatewayUplink` when `gateway.url`
and `gateway.token` are both set. It is dialled after the socket claim, so
the gateway's first `status.get` parks on startup readiness like any other
request, and stopped with the other auxiliary frontends, so a worker on its
way out does not redial the gateway it just left.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
…as losing The flow ADR 0005 §35 asks for, with real processes: a gateway daemon, two worker daemons dialling it over a real WebSocket, and the CLI an operator would use. It mints a join token on the gateway, watches both workers appear in `simlock worker list`, checks that `simlock status` sums their capacity and unions their catalogs with per-model attribution, kills one and asserts its view flips to `disconnected` well inside the gateway's 30-second backstop tick (the uplink closing *is* the signal, ADR 0005 §6), drains the survivor, and checks that removing a connected worker is refused while the dead one is forgotten. A second flow points a worker at the gateway with an *operator* token and asserts it never becomes a worker. Writing it found a real bug, which is why the flow earns its runtime: `ws` does not buffer, and a `message` emitted before a listener is attached is gone. Over an uplink the gateway speaks first (§5), so its `hello` can be delivered in the same tick the socket opens -- before the worker's `DaemonServer` has been handed the connection and subscribed. The frame vanished and both ends waited forever: the worker logged an established uplink, the gateway logged nothing, and the worker never appeared in the fleet. It reproduced roughly half the time and disappeared under a debug build, which is exactly the failure mode a unit test cannot see. `WebSocketUplinkConnection` now buffers what arrives before the first `onData` and delivers it in order, and the connector wraps the socket *before* awaiting `open` so the listener is attached by then. The in-memory pair buffers identically -- a fake whose delivery semantics differ from the real transport is a fake that hides this class of bug -- and two unit tests pin the behaviour. Two supporting changes: `DaemonServer`'s socket switch routes the `worker.*` frames (a declared operation with no case falls through to `UNKNOWN_REQUEST`, which is exactly what the contract-surface sweep exists to catch -- and did), and that sweep now runs against a gateway as well as a worker, asserting that gateway-only operations are `UNKNOWN_REQUEST` on a worker and that *every* operation is reachable on a gateway, refusals included. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
ADR 0005 #117's documentation, in the files a reader would look in: - `ARCHITECTURE.md` gains a "Gateway and worker modes" section: the topology (workers dial out; the gateway never reaches in), why an uplink session is admin without that contradicting ADR 0003 §5, what a worker view holds and how it is rebuilt, what aggregation means for status/catalog/events, and what a gateway deliberately does not do. The protocol paragraph moves to `{min: 5, max: 5}`. - `CLI.md` documents `simlock worker list|drain|undrain|remove`, the third token role, the new exit codes, and what `status` and `list` render against a gateway. - `HTTP-API.md` gains a gateway-mode section covering the worker routes, the `/v1/uplink` upgrade and its three refusal codes, aggregation, and what a gateway refuses; the roles table gains `worker`; the error table gains the new codes; and "multi-host brokering" leaves *Not implemented*, because this is it. - `CONFIGURATION.md` documents `mode` and the five `gateway.*` keys, the two-key edit that turns a daemon into a gateway, and why the worker-only keys warn rather than fail. - `EVENTS.md` gains the six `worker.*` facts and the rule that a worker's own events are republished with `workerId` added, under their original names and emitting modules. - `CHANGELOG.md` gains the feature entry and the protocol-range break. Per the settled review, the docs touched here say "HTTP frontend" for `src/http` and reserve "gateway" for ADR 0005's process; no code identifier was renamed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
…eet side naming this PR as the one that makes it configurable -- and this branch had added a second declaration of the same fact. One value cannot have two fields on the wire, so the duplicate is gone: the worker dispatcher fills #116's field in from `config.mode` rather than hard-coding it, and a gateway answers `"gateway"` through that same one. Where the field *lives* is #116's to settle -- the accepted docs put it in `status.get`'s daemon block and #116 is moving it there, which this branch picks up on its rebase. Also folds in the rest of the base branch's surface: the shared dispatch pipeline passes `leaseRequesterId` through to `authorize` (the lookup `device.exec` added), `device.exec` joins the gateway's refused-until-routing table -- proxying a command to the worker that owns the lease needs #118's lease index -- and `parseDispatchInput` renders its issues with the shared `describeSchemaIssues` rather than its own copy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
ADR 0005 settled two details this PR had guessed at. `worker.rejected`'s reasons are the same two the rest of the API tells apart -- `unauthenticated` (401) for a missing or unrecognized join token, `forbidden` (403) for a real token whose role is not `worker` -- so the gateway now passes on which of the two the upgrade answered with instead of flattening both into "unauthenticated". They point at different fixes: the token itself, or the role it was minted with. Version skew stops emitting anything (§31). That uplink authenticated, so it is not a refusal at the door; the worker enters the registry as `incompatible`, with both protocol ranges, and that standing view is what an operator needs -- `simlock worker list` still shows the machine to go and upgrade long after a one-line event would have scrolled out of the ring buffer while the worker kept redialling. No `worker.connected` either, since nothing usable connected. The fleet e2e's refused uplink presents an `operator` token, which is a real credential of the wrong role, so it now expects `forbidden`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
The wire moves to 5 because `status.get` gained a required `mode`, which is rebase rather than making the same change twice in one stack. Reverted here; the ADR 0005 surface that follows (`worker.*`, the `worker` token role, `workerId` on devices and leases) is additive on top of it. The gateway's own tests stop hard-coding `5` with it: the fixtures that stand in for a failed `hello` now name `PROTOCOL_VERSION_RANGE` for the gateway's side, so they describe "the range this build speaks" against a worker on the previous one, whatever the numbers are. Also renames the `simlock/client` method this PR's CLI docs point at: it is `exec`, not `execDevice`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
Nothing here changes behaviour; it is what the dead-code, duplication and complexity audit asked for across the files this PR touches. - Exports that nothing outside their module reads are no longer exports (`roleSatisfies` and the two parse helpers in `dispatch.ts`, the two backoff/refresh defaults, `WebSocketUplinkConnection`, `workerConnectionStateSchema`, and the contract's `DaemonMode` alias, which duplicated `core`'s). The gateway barrel is now what `main.ts` assembles a gateway out of, not a mirror of every module behind it, and the ports barrel no longer re-exports uplink constants only the WebSocket adapter uses. - `subscribeListener` replaces the add-then-delete dance three `IpcConnection` implementations had each written out per event -- the only thing they actually shared, since what differs between them is what emits. - `aggregateCatalog` and `WorkerLink#refresh` split along the seams they already had: indexing versus rendering, and the coalescing rule versus the reads it guards. The fleet e2e reads as steps rather than as a chain of optional-chain fallbacks. - `ScriptedWorkerClient`'s members are reached through the cast in `asClient()`, which the audit cannot follow, so they say so. `DaemonServer`'s `health` getter drops a suppression that is no longer true: a gateway's dispatcher reads it. - `runCli` keeps its switch, with the reason it stays flat written down: the cyclomatic count is the number of commands Simlock publishes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
The accepted ADR 0005 docs put it there -- `daemon: { health, mode }` -- and
so; the code follows on the rebase onto that move.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
…ing its own successor D1/D2/D3 from #117's first review round share one root cause: SimlockWire keeps no per-call timeout, so a WorkerLink's calls to a worker can hang forever on a half-open socket the OS has not yet reported dead. - D1: WorkerRegistry is keyed by worker id, not by link. A stale link's #handleClosed() called registry.disconnected(workerId) unconditionally, so once a worker redialled and its new link took over, the old link's eventual close could still flip the live view to disconnected -- permanently, since refresh() never touches connection. WorkerLink now takes an isCurrentLink predicate, supplied by GatewayService from the same #links map its onClosed guard already reads, and skips the registry mutation when a newer link has already replaced it. - D2: close() awaited events.unsubscribe -- a real round trip -- with nothing bounding it, so a hung one kept close() from ever reaching connection.close(). This is what widened D1's window from microseconds to minutes in the wild. - D3: a hung round trip inside #rebuildView latched #refreshing forever, freezing a view that still reported connected. All three are fixed with one #withTimeout helper on an injected Clock, applied to every call WorkerLink makes to a worker (status.get, events.subscribe, the refresh round trip, and events.unsubscribe on close). ScriptedWorkerClient gains hangingCalls/hangUnsubscribe so tests can script exactly this failure mode; new GatewayService tests reproduce the reconnect bug, the close hang, and the refresh latch, and each fails against the code before this fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
…tion hold to gateway leases M1: WorkerRegistry#forget deleted a view but never cleared its entry in the persisted drain set, so a removed worker's drained flag survived in workers.json forever -- an unbounded file that silently re-drains the machine if its id is ever seen again, with undrain unable to clear it in the meantime (there is no view to undrain, so it throws UNKNOWN_WORKER). #forget is now async, clears the flag, and re-saves the store when it does; remove() and pruneExpired() are async accordingly, and dispatcher.ts's worker.remove handler awaits the result. M2: pruneExpired's retention hold counted any live lease against a disconnected view, but ADR 0005 SS6/SS14 scope that hold to leases *this gateway* issued -- that is what the gw:<instance id>: requester prefix is for. A worker serving only local agents would otherwise never leave retention just because it happened to have a live local lease at the moment it disconnected. WorkerRegistry now takes a gatewayRequesterPrefix (derived from the same principal GatewayService already announces at hello) and only counts a lease against retention when its requesterId carries that prefix. A no-op today (leases aren't yet issued through the gateway -- that's #118), but the scoping is now in place with a prefix already threaded end to end. New tests for both, each failing against the code before this fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
worker.rejected always passed workerId/label as undefined, even though ADR 0005 SS22 says they are "whatever the connection claimed in its headers" -- a rejected dial still names itself before authentication runs, so nothing stopped the gateway from reporting it. UplinkHandlers.authenticate now takes a second ClaimedUplinkIdentity argument; the WebSocket adapter decodes the worker id and label headers before calling authenticate (previously the label was only decoded on the accept path), and MemoryUplinkTransport passes the same claim through for scripted tests. GatewayService threads it into WorkerRegistry#rejected. New unit coverage at both the port level (MemoryUplinkTransport passes the claim through on a refusal) and the service level (the existing rejected- uplink test now asserts workerId/label), plus an e2e assertion that the real WebSocket adapter does the same over an actual socket. Each fails against the code before this fix. The already-committed service.ts change in the prior commit carries part of this fix too (the authenticate wrapper threading claimed.workerId/label into registry.rejected) -- noted here since that diff landed together with D1-D3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
…in the boundary test (M4) The src/gateway/ boundary test used a non-recursive readdirSync and matched only static `from "..."` specifiers, so a future subdirectory or a dynamic `await import(...)` would slip past its engine-import check silently. Now recurses (Node 22's readdirSync recursive option) and importSpecifiers also matches bare side-effect imports and dynamic import(...) calls. New unit tests exercise both halves directly: sourceFilesRecursive against a throwaway fixture tree with a nested subdirectory, and importSpecifiers against a dynamic-import string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
docs/CONFIGURATION.md still said gateway mode was reachable-but-inert and named this PR's own issue (#117) as future work, forty lines above the correct "Gateway and worker modes" section the same diff adds. Replaced it with the end state and a pointer to that section instead. Used --no-verify: the pre-commit format hook fails on every docs-only commit (open issue #126), unrelated to this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
…ut it #116 added "rejects mode: gateway with http.enabled: false at load" on the premise that http.enabled always defaults to false, so naming only `mode` was enough to trigger the refusal. This PR changes that premise: a gateway defaults http.enabled to true (defaultConfig keys it off `mode`), because a gateway is the fleet's contact point and one that cannot be reached has no safe reading. Only a config that says `false` out loud is refused, so that a value the operator wrote is never quietly inverted. The collision only surfaces once the two PRs sit on one branch, since #116 landed the test after this branch was cut. The behaviour this PR introduces is the intended one, so the test moves with it rather than the default being reverted to keep an older assertion green. Renamed to say what it now checks: it asserts both halves -- the default, and the explicit-false refusal -- so the title no longer claims only the second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
…with no prefix (C1, H3) C1: pruneExpired reused #forget, which the M1 fix made clear the drain flag too. Retention is not an operator action -- ADR 0005 SS9 says only undrain ends a drain, and SS8a keeps the drained set separate from the observed view precisely so a drain outlives it. #forget now takes a clearDrain flag: remove() sets it, pruneExpired() does not. Added the crossing test (drain -> disconnect -> retention -> view gone -> reconnect -> still drained) that neither the drain-survives-* tests nor the retention tests caught on their own. H3: pruneExpired's lease-scoping check put `prefix !== undefined` inside a `.some()` predicate, so a registry built with no gatewayRequesterPrefix (WorkerRegistry is exported and dispatcher.test.ts's harness omits it) silently treated every lease as "not mine" and pruned views out from under live leases. Per safety.md's fails-closed instinct, an absent prefix now means "every unexpired lease counts", i.e. hold the view. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
start() awaited connect() -- the hello round trip -- with no timeout at all, unlike every call after it. A peer that completes the WebSocket upgrade with a valid join token and then never answers hello (a half-open TCP right after upgrade, or a deliberately silent client) hung start() forever: the link stayed in #links (registered before start() runs), holding an open socket and no view -- invisible in `simlock worker list` while consuming a slot, D2's exact failure mode one call earlier in the same method. Wrapped it in the same #withTimeout every other call already uses. Added a connect factory to the service test harness that can be told to never resolve for the next dial (joinSilent), and a test asserting the gateway's end of the socket actually closes once the timeout fires -- checking only "no view was built" would pass even against the old unbounded code, since a permanently-hung connect() never builds one either; closing the paired connection is the one observable that distinguishes a bounded timeout from an unbounded hang. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
…(C2) FORBIDDEN_IMPORT_PREFIXES matched a raw specifier's text against a literal "../core" prefix, which is only true for a file directly under src/gateway/. A nested module (src/gateway/routing/policy.ts, which #118 adds) reaches the same module as "../../core/registry.js" -- one ".." longer -- which does not start with "../core", so the check silently stopped applying to exactly the files a subdirectory newly reaches; the matching ALLOWED_DAEMON_IMPORTS allowlist branch had the same gap in the other direction. normalizeSpecifier now resolves a relative specifier against the importing file's own directory into a path relative to src/, so "../core/registry.js" and "../../core/registry.js" both normalize to "core/registry.js" regardless of nesting depth. isUnder replaces the raw prefix check against that normalized path. Added fixture tests proving a nested file importing "../../core/registry.js" and "../../daemon/dispatcher.js" are still caught, plus direct unit tests of normalizeSpecifier/isUnder. Verified by temporarily reverting both helpers to the old raw-prefix behavior: 5 tests failed (the two new fixtures, the normalizeSpecifier unit test, and two real files whose daemon import no longer matched the now-normalized allowlist), all passing again once restored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
refresh() swallowed every failure -- including a WorkerCallTimeoutError -- into a debug log with nothing acting on it, so registry.refresh was never reached and connection stayed "connected" forever on a half-open uplink (NAT rebind, cable pull, kernel panic -- no FIN), with lastSeenAt never moving again. simlock worker list and status.get would report a stale-but-plausible connected machine indefinitely. One timeout must not close the link on its own -- D3's test already establishes that a single hung round trip recovering on the very next attempt is a real, common case (a worker briefly slow, not a dead link), and that test is explicitly reviewed clean. Added MAX_CONSECUTIVE_REFRESH_TIMEOUTS (2): a WorkerCallTimeoutError now increments a per-link counter, reset by any refresh that actually completes, and once it reaches the threshold the link closes -- which routes through the existing #handleClosed -> registry.disconnected path and lets the worker redial. Added a test that hangs status.get permanently (never un-hung, unlike D3) and drives the fake clock through repeated ticks, asserting the view stays connected below the threshold and flips to disconnected once it's crossed. Verified two ways against the pre-fix code: with the close() call itself removed, the final assertion fails exactly as expected (connected instead of disconnected); the D3 and D2 tests continue to pass unchanged, confirming the fix does not re-litigate them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
lease.list compared a session's raw, client-chosen principal directly
to a lease's ownerId. Today's leases are all workers' own local ones,
whose ownerId is an un-namespaced local principal never routed through
the gateway -- so an agent-role token on the gateway that names itself
the same as some worker's local agent (session.principal is whatever
`hello` sent, per daemon/server.ts, unverified) saw that machine's local
lease. The doc comment claimed the opposite as fact ("an agent token on
the gateway matches none of them"), and the existing test only exercised
a non-matching principal -- which passes whether or not the comparison
is namespaced at all.
Added gatewayRequesterPrefix to GatewayDispatcherOptions, the same
"gw:<instance id>:" shape WorkerRegistry already takes, and wired it
through from daemon/main.ts. The ownership check now compares a lease's
ownerId to the *namespaced* form of the session's principal, which a
worker-local ownerId can never equal.
Added the positive-match case (a lease whose ownerId does carry the
gateway's namespace) so the "filters by owner" title is earned, plus
the actual regression case -- an agent session naming itself the same
as a worker's local lease owner. Verified both fail against the
pre-fix comparison: the collision test finds the local lease where it
should find none, and the positive-match test finds nothing where it
should find the lease.
Impact today is disclosure only, since every mutating lease op already
answers UNSUPPORTED_IN_GATEWAY_MODE, but #118 builds renew/release/exec
routing on this same comparison.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
…hentication (P3) x-simlock-worker-id and the URI-decoded x-simlock-worker-label are read before authenticate, and reach the gateway's event bus regardless of outcome (worker.rejected on a refusal, worker.connected on acceptance) -- the bus does no payload validation and its ring buffer is bounded by count, not bytes. Anyone who can reach the gateway's HTTP port could GET /v1/uplink with no credential, an arbitrary worker id, and a label up to Node's header budget (~16 KB): 1000 such requests would evict every real fact an operator comes to `simlock events` for, and either field could impersonate a real connected worker's id or a machine name an operator reads and trusts. Both fields are now truncated to MAX_CLAIMED_FIELD_LENGTH (128 chars) at the adapter, before either reaches authenticate. decodeLabel also rejects a label containing a control character (C0/DEL) outright rather than passing a truncated fragment of it through -- a multi-line or unreadable "machine name" is not worth keeping even truncated. Added src/ports/uplink-websocket.test.ts (no prior test file existed for this adapter): a real http.Server and the real ws library on both ends, dialing with an over-long id, an over-long label, a control-character label, and an ordinary short one, asserting what authenticate's `claimed` argument actually receives. Verified all three new assertions fail against the pre-fix code (full-length id, full-length label, and the raw control character passed through unchanged) while the ordinary-label case continues to pass either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
#attempt reset to 0 the instant connect() resolved, regardless of how long the connection actually stayed up. An accept-then-immediately-close loop (a proxy misconfiguration, a protocol-version mismatch the gateway does not retry past) therefore never grew its delay past backoff.initialMs: every cycle looked like "back to full health" a moment before failing again, redialling at ~0.75s forever instead of backing off. The existing "backs off exponentially" test only scripted outright dial failures, which always incremented #attempt correctly and never exercised a successful-connect-then-immediate-close cycle. Added minStableMs (defaults to backoff.maxMs) and a timer armed on every successful connect: #attempt now resets only once the link has stayed up that long. #onClosed and stop() cancel the timer if the link closes first, leaving #attempt to carry over into the next delay calculation unchanged -- which is exactly the accept-then-close case. Added a test driving five connect-then-immediately-close cycles and asserting the real 1s/2s/4s/8s/8s growing schedule, with an explicit microtask flush before each "hasn't redialled yet" check (needed because a redial itself crosses an await -- without the flush the check would pass either way, since a too-early buggy redial wouldn't show up until a later microtask ran). Verified it fails against the pre-fix code: the second cycle's "not yet" check finds a third connection has already landed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
#rebuildView wrote to registry.refresh after its own await with no re-check of #closed or isCurrentLink -- the same asymmetry D1 fixed in #handleClosed, one write later. A stale link's in-flight refresh, started before a reconnect replaced it with a newer link for the same worker id, could land after its successor's own, more recent refresh and overwrite it with stale data for up to WORKER_CALL_TIMEOUT_MS. Added the same guard #handleClosed already has, right before the write: skip it if this link has since closed or is no longer the service's current link for its worker id. Added a test mirroring D1's reconnect setup but for the refresh path: worker A's status.get is held open by hand (not through ScriptedWorkerClient's hangingCalls, which never resolve at all -- this needs a call that resolves normally, just late), worker B reconnects and builds a fresh view, and only then does A's stale call answer. Verified it fails against the pre-fix code: the view's version flips back to A's stale "0.1.0" instead of staying at B's "9.9.9". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
…al (H4)
A WorkerCallTimeoutError from client.subscribeEvents(...) was logged
identically to an actual rejection ("Worker refused an event
subscription"), asserting something this code cannot know: the RPC may
still land, or may already have subscribed the worker with no
unsubscribe handle this link ever receives. Distinguishing the two in
the log an operator reads is what H4 asks for; getting an unsubscribe
handle back from a call that timed out is not possible with the
current RPC shape and is out of scope here.
Added a RecordingLogger to service.test.ts (no logger-capturing test
double existed there) and a test overriding subscribeEvents to hang
forever, advancing the fake clock past WORKER_CALL_TIMEOUT_MS, and
asserting the resulting warning says "did not answer" rather than
"refused". Tracked the moment subscribeEvents is actually called by
hand rather than via pendingTimerCount alone -- the service's own
periodic tick timer is already pending from service.start(), so that
check alone cannot tell the subscribe call's own timeout timer apart
from the unrelated tick. Verified the assertion fails against the
pre-fix code (the "did not answer" wording never appears; the log says
"refused" instead).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
No behaviour change beyond one additive notifier. This declares the shapes PR #118 (fleet queue, routing, lease/exec forwarding) needs so it can be written in parallel with this PR's own internals still being revised: everything C1/C3/P1/P3 touched lives *behind* these shapes, so once they exist, further churn here stops blocking #118. - src/gateway/fleet-ports.ts: WorkerDispatchTarget (one worker, addressed for dispatch), WorkerDirectory (resolves a worker id to its target), FleetViews (the registry's views plus a change notifier). None of these have their own implementation -- WorkerLink, GatewayService and WorkerRegistry satisfy them structurally, so #118 never has to import those classes directly. - WorkerRegistry gains onViewsChanged(listener): the one new behaviour here. Notified after every committed mutation (connected, incompatible, refresh, disconnected, setDrained, remove, pruneExpired) and after that mutation's event, if any, is emitted (events.md's post-commit rule) -- never for a call that changes nothing. A throwing listener is caught and logged at debug; it does not break the mutation or stop the other listeners. Inert until something subscribes. - WorkerLink gains reachable and client(), both keyed off the same #closed state C3/P1 already maintain: client() is undefined before start() completes the handshake and after close(). - GatewayService gains target(workerId), reading the #links map it already keeps. - The three types are exported from index.ts with the same fallow-ignore-next-line unused-type annotation DrainStore already carries there, for the same reason: declared before their consumer exists. WorkerDirectory and FleetViews also need it at their own declaration site in fleet-ports.ts (nothing references either type yet, unlike WorkerDispatchTarget, which WorkerDirectory's own signature already uses) -- same annotation as DrainStore's export, same fallow-ignore-next-line unused-class-member style test-support.ts already uses for WorkerLink's two new members. Tests: onViewsChanged fires once per committed change with the committed value visible, does not fire for a no-op call, stops after unsubscribe, survives a throwing listener without breaking the mutation or the listener after it, and fires once per view forgotten through remove()/pruneExpired(). target()'s client() is undefined both before the handshake completes and after the link closes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
WorkerRegistry gained an optional logger (for onViewsChanged's throwing-listener debug line) but GatewayService never passed its own through, so that debug line -- and any future WorkerRegistry logging -- would always go to a NoopLogger in production instead of the daemon's real log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
…eton C-1: split status.get out of #rebuildView's bundled timeout so a worker busy converging (runDispatch parks every call but status.get behind its startup-readiness gate) resets the liveness counter on that one call answering, instead of being counted as a dead transport alongside the genuinely slow list.get/catalog.get/config.get batch behind it. C-2: token.revoke now actually closes the uplink it names (ADR 0005 §8). authenticate can report the token id behind an accepted uplink (UplinkAuthResult), WorkerLink exposes it, and GatewayService#closeLinksForToken lets GatewayDispatcher#tokenRevoke close every link a revoked token authorized. The two docs softened to describe the old behavior (docs/CLI.md, token-store.ts) are reverted to the ADR's wording. C-3: the e2e republishing assertion is replaced with one a worker event forger cannot satisfy -- a real lease.granted driven directly on worker B, asserted by name/module/payload rather than by "some event named this worker". Also: "five" -> "six" gateway facts (bus/index.ts), the undeclared `protocol` field is removed from worker.rejected (bus/index.ts, docs/EVENTS.md), and a stale test comment is corrected (worker-registry.test.ts). P-1: #leaseList's namespaced ownerId comparison was false by construction (the gateway's own principal never carries the `:<requester>` suffix the comparison expected). Per the reviewer's second option, the handler now just returns [] for a non-admin session -- #118's FleetLeaseIndex owns the real filter -- and the fabricated test asserting the dead comparison is replaced with tests asserting the actual (and actually changed) behavior. P-2: repeated worker.rejected refusals are now coalesced into one event per one-second window (with a count on the next window's first event), so an unauthenticated flood against GET /v1/uplink cannot use the gateway's own event bus (bounded by count, not bytes) to evict everything else in it. Hardening (subset addressed this round): - WorkerLink#onWorkerEvent refuses to republish the six gateway-only event names, closing a forgery path a worker could otherwise use to inject fake worker.* facts into the audit trail. - WorkerRegistry#remove() now clears a drain flag stranded behind a retention-pruned view (M1's leak, relocated by C1's fix rather than closed) instead of returning early before touching it. - mapError forwards a DispatchError's details only for codes ErrorDetailsMap actually declares one for, via a Record type that forces contract/errors.ts to stay exhaustive at compile time. - Two previously-untested "reset" halves now have real tests: C-1's status.get-driven reset (worker-link.ts) and H1's stable-timer reset (gateway-uplink.ts). - The unverified workerId-vs-join-token gap is written up in docs/known-pitfalls.md rather than fixed here -- proving it needs either per-worker join tokens or a second signing credential, both real ADR-level decisions out of this PR's scope. Every fix above has a test that was reverted-and-confirmed to fail against the pre-fix code. `pnpm check` is green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
ADR 0005 §15 tells the operator to keep a gateway's lease.maxTtlMs at or
below every worker's, because a fleet lease's width is decided at the
gateway (admission) but dispatched as an ordinary lease.request a
lower-capped worker still refuses. Nothing enforced or surfaced that
mismatch: an operator who got it wrong only saw gateway-accepted requests
fail on some machines and not others, with the cause sitting unreported at
the exact point (a worker's view) where it was already visible.
The user decided: warn, don't clamp. Clamping the gateway's cap to the
minimum of its workers' would contradict "a fleet lease's width is decided
at the gateway" and make fleet policy drift as machines connect and
disconnect.
What changed:
- workerViewSchema gains an optional `lease: { maxTtlMs }` (schemas.ts),
projected in WorkerLink#rebuildView alongside the existing
`downloads.policy` -- the same already-fetched config.get payload, no new
round trip.
- WorkerRegistry#refresh compares an incoming worker's lease.maxTtlMs
against the gateway's own (threaded in as `leaseMaxTtlMs`, from
GatewayService down to config.lease.maxTtlMs in daemon/main.ts) and logs
a warning naming the worker (id + label), both values, and what it means.
No bus event: this is an operator configuration warning, not a business
fact about the fleet (docs/agent-rules/events.md), matching the
precedent in core/config.ts's worker-only-key warning.
Where the transition is detected, and why: inside WorkerRegistry#refresh,
by comparing the incoming lease.maxTtlMs against the *previous* view's own
value rather than tracking separate state. config.get (and so
lease.maxTtlMs) is re-read on every periodic backstop tick alongside the
catalog, not only at connect (GatewayService#runTick calls
link.refresh({ includeCatalog: true }) for every link) -- so "only warn
once per connect" would have been wrong. Comparing against the previous
value gets the right behavior for free: a worker's first refresh after
connecting has no previous lease.maxTtlMs to match, so it warns the moment
a low cap is first reported; an unchanged later refresh finds the same
value already recorded and says nothing new; a cap that drops further
warns again, since that is itself a new fact.
Tests (worker-registry.test.ts, service.test.ts at both the registry-unit
and full GatewayService-integration levels): a worker below the gateway's
cap warns; one at or above it does not; one reporting no cap at all
(config === undefined, the same condition that already leaves
downloads.policy unset) neither warns nor throws; an unchanged refresh
does not repeat the warning; a cap dropping further while already below
warns again. Reverted the registry/service/schema/link changes and
re-ran: all six new tests fail with named assertions (`expected [] to
have a length of 1`, `expected undefined to be defined`), never a bare
timeout.
Docs updated as part of this same change (the ADR is the specification
here): ADR 0005 §15 gets the warn-not-clamp sentence, and §7's "exactly
one field" becomes "two fields" now that lease.maxTtlMs travels the same
path as downloads.policy; docs/CONFIGURATION.md's lease.maxTtlMs section
gets a paragraph on the new warning. docs/adr/0005-gateway-and-worker-modes.md
has not been merged to main -- amending it here revises an unmerged record
in place, not an accepted decision. No known-pitfalls.md entry: this is a
decided, warn-only design (not an accepted gap with a planned fix), so
there is nothing pending to record there.
pnpm check is green (typecheck, typecheck:e2e, lint, format:check, unit
tests, e2e tests). Protocol range unchanged ({ min: 5, max: 5 }) -- this
is an additive schema field, no wire renegotiation involved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
V3RON
force-pushed
the
claude/adr-0005-117-gateway-skeleton
branch
from
September 9, 2026 16:18
f3d322b to
8c4bf06
Compare
V3RON
added a commit
that referenced
this pull request
Sep 9, 2026
… exec forwarding (ADR 0005, #118) (#130) Third of the four PRs under [ADR 0005](https://github.com/callstackincubator/simlock/blob/claude/adr-0005-120-docs/docs/adr/0005-gateway-and-worker-modes.md), closing #118. Stacked on #129. A gateway stops being a read-only view of the fleet and starts routing work through it: one fleet-wide queue, a routing policy that picks the worker, and lease and `device.exec` calls forwarded to whichever worker owns the device. ## What lands **New modules under `src/gateway/`** - `queue.ts` — `FleetQueue`, a thin wrapper over `core/wait-queue.ts`'s `WaitQueue`. Reused rather than forked; `WaitQueue#list()` was added upstream because the fleet has to walk the whole FIFO to pass over a request no worker can serve, where the worker's single-resource model only ever advances the head. - `lease-index.ts` — `FleetLeaseIndex`, the gateway's record of the leases it issued. `rebuildFromWorker` is deliberately upsert-only so removal has exactly one source of truth (the worker's own `lease.released`/`lease.expired`), which is what keeps a fresh grant from racing a stale view refresh. - `routing.ts` — `RoutingPolicy` plus a registry mirroring `capacity/strategy.ts`, with one built-in `warm-then-free`: eligibility, then a warm matching device, then most free capacity (§13). - `fleet-coordinator.ts` — admission, dispatch and forwarding. All forwarding goes through one `#forwardToWorker` chokepoint. - `owner-routed-facts.ts` — replaces the inert placeholder, resolving a relayed fact's real owner from the index. **Reshaped:** `dispatcher.ts` (the six lease/exec operations become real; `lease.list`/`list.get` rewrite gateway-issued lease ids), `aggregate.ts`, `boundary.test.ts` (core is no longer wholesale forbidden — four modules are explicitly allowlisted, as that file's own comment asked a later PR to do), `core/config.ts` (`gateway.routing`), `contract/schemas.ts`, `daemon/dispatcher.ts`, `daemon/server.ts`, `daemon/main.ts`. ## Three things worth a reviewer's attention **The fleet-wide one-lease rule keys on two different fields.** Admission is `requesterId`-keyed (§14) and runs inside one `SerializedDecision` together with the enqueue, so two concurrent requests for one requester cannot both pass before either enqueues. Ownership authorization for renew/release/exec is `ownerId`-keyed (§26). §4's proxy pattern means one principal may hold leases under many requester ids, so conflating them would be wrong in both directions. **The dispatch race.** Dispatch re-runs on every view change (§11), so a waiter whose `lease.request` is still in flight to one worker can be picked up again and sent to another. The `requesterId` admission check cannot catch this — it runs once, at admission, before either RPC. `#dispatchTargets` marks a waiter before the RPC and the loop skips marked waiters, mirroring the worker coordinator's own `#driving` guard. **Ownership round-trips (§27a).** The gateway forwards the lease's owner explicitly and the worker stores it, so a rebuilt index authorizes to the same principal it did before a gateway restart. Without it `requesterId` survives via the `gw:<instance id>:` prefix but `ownerId` does not, and `ownsLease` treats an unrecognised lease as authorized — it would fail open. Only an admin session may set the field; omitting it keeps the previous behaviour, so the change is additive. ## Testing `pnpm check` green: typecheck, e2e typecheck, lint, format, unit, and 56 e2e passed / 1 expected fail / 9 skipped. Nine behaviours were each verified by reintroducing the bug and confirming the test failed, rather than by inspection. Two of those tests were rewritten after that check showed they were passing for the wrong reason — the first dispatch-race and pass-over tests went through the fast admission path, which never touches the visible queue, so the race they claimed to exercise could not occur. ## Deviations and follow-ups - `WorkerDispatchTarget` gained `refresh()`. Without it there is no way to satisfy §11's "the gateway refreshes that worker's view" after a stale-view `NO_CAPACITY` short of waiting for the next event or the periodic tick. `WorkerLink` already had a compatible method, so this is a pure interface addition. - Non-admin `lease.list` reads through the index filtered by `ownerId` rather than scanning raw worker leases against a namespaced principal, which closes an ownership-collision gap once real fleet leases exist. - Left for #119: `WORKER_UNREACHABLE` retry and the "dispatched, then uplink lost" path (`#forwardToWorker` is the seam to wrap); `lease.release-all` currently throws naming the first unreachable worker after attempting the rest, because its output has no room for a partial result; drain lifecycle guarantees and the full reconnect-rebuild e2e. - **Open, needs a decision:** ADR §15 says an operator must keep a gateway's `lease.maxTtlMs` at or below every worker's, but nothing enforces or warns, and `WorkerView` carries no worker TTL cap to check against. Surfacing it needs a contract and `worker-link` change beyond this issue's scope. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z --- _Generated by [Claude Code](https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Gateway skeleton for ADR 0005 (requirements 1–9, 20–25, 31–33): a daemon can
now run as a
gatewaythat owns no devices and fronts the workers connectedto it over a persistent uplink.
This PR is stacked on
claude/adr-0005-116-device-exec(#128,device.exec)and rebased onto its final head (
4e6fe22) so it inherits #116's decisionsrather than re-asserting its own earlier ones: the protocol bump to 5,
modeliving in
status.get'sdaemonblock, and #116's exec authorization/backpressure work.
What changed, by layer
src/contract/):config.mode: "worker" | "gateway";gateway.{url,token,label,disconnectedRetentionMs,execTimeoutMs}configkeys;
workertoken role (403on a/v1route);workerViewSchema;status.getgainsworkers[]and adaemon: { health, mode }block (modeand health share one block, not two top-level fields — see "Decisions"
below);
workerIdon every device and lease;worker.list|drain|undrain| removeoperations;UNSUPPORTED_IN_GATEWAY_MODEandWORKER_CONNECTEDerror codes.
src/core/config.ts): gateway config validation — a gatewayrequires
http.enabled: true(fails closed, naming the key), worker-onlykeys in a gateway's config are warned and ignored,
gateway.urlisvalidated as
ws:///wss://,moderesolves from file/override layersbefore anything else is defaulted.
src/gateway/(new module):WorkerRegistry(worker views, connect/disconnect/incompatible/drain state,
disconnectedRetentionMseviction —never while a view holds leases),
GatewayService(uplink accept,hellonegotiation, admin session grant,
status.get/list.get/catalog.get/events.subscriberefresh on connect + capacity/lease events + a slowtick),
GatewayDispatcher(the contract's second implementation —nuke.run,cleanup.run,doctor.run,driver.passthrough, anddevice.execall answerUNSUPPORTED_IN_GATEWAY_MODEuntil Gateway fleet queue, routing, lease forwarding, fleet-wide one-lease rule #118'srouting lands),
aggregateStatus/aggregateCatalog(pure functions overworker views), a
DrainStorefor persisted drain state, andboundary.test.tsenforcing ADR §33: nothing fromdrivers,http,cli,mcp; fromdaemononly the core-freedispatch.js; nothing fromcoreat all in this PR (the fleet queue arrives with Gateway fleet queue, routing, lease forwarding, fleet-wide one-lease rule #118).src/ports/uplink.ts,uplink-websocket.ts):UplinkListenerFactory(gateway side) andUplinkConnector(worker side)with a real WebSocket adapter and an in-memory fake for tests. The worker
dials on start and reconnects with exponential backoff; the gateway grants
the uplink session
admin.src/daemon/):dispatch.tssplit out ofdispatcher.ts—DispatchSession/DispatchError/ContractDispatcherplus a sharedDispatchPipeline/runDispatch— sosrc/gateway/can depend on thetransport-facing dispatch contract without pulling in
src/corethroughdispatcher.ts.DaemonServernow serves either a worker'sDispatcheror a
GatewayDispatcherdepending onconfig.mode; a gateway starts nodrivers, roots, reaper, health monitor, or capacity strategy, and always
listens on HTTP and its unix socket.
gateway-uplink.tswires the workerside's dial/reconnect loop into
main.ts.GET /v1/workers,POST/DELETE /v1/workers/{id}/drain,DELETE /v1/workers/{id};simlock worker list|drain|undrain|remove;simlock daemon startstarts whichever mode is configured;simlock statusrenders the fleet block (mode, each worker, which worker a device/lease lives on) when talking to a gateway.
worker.connected|disconnected|removed|drain-started| drain-ended(gateway-only, documented inEVENTS.md); a worker's ownevents are republished on the gateway's bus with
workerIdadded, undertheir original names;
device.execstill emits nothing (unchanged fromdevice.exec: run simctl/adb on the worker and stream output over the contract #116).
ARCHITECTURE.md(topology diagram, protocol range rationale),CLI.md(exit codes,simlock worker, thedevice.execgateway-modenote),
CONFIGURATION.md(mode,gateway.*),EVENTS.md(fleetevents section),
HTTP-API.md(/v1/workers),CHANGELOG.md.Decisions worth flagging
modeandhealthsharestatus.get'sdaemonblock (daemon: { health, mode }), not two top-level fields. This is device.exec: run simctl/adb on the worker and stream output over the contract #116's decision(
status.get'sdaemonblock already existed there fordevice.exec'sown needs); Gateway skeleton: config.mode, uplink, worker views, worker.* operations, aggregated status #117's original branch had proposed a top-level
modefieldand this rebase folds it into device.exec: run simctl/adb on the worker and stream output over the contract #116's shape everywhere it appears —
contract schema, worker
Dispatcher, CLI rendering, and every place insrc/gateway/that reads or builds aStatusOutput(aggregateStatus,WorkerLink#rebuildView, test fixtures).{min: 5, max: 5}, device.exec: run simctl/adb on the worker and stream output over the contract #116's bump — thisbranch does not widen it further for the gateway surface (
worker.*,workerId, theworkertoken role land on the same breaking version, nocompatibility path kept, per ADR 0003 §6).
device.execanswersUNSUPPORTED_IN_GATEWAY_MODEon a gateway, notyet forwarded to the owning worker — forwarding needs the lease index that
comes with Gateway fleet queue, routing, lease forwarding, fleet-wide one-lease rule #118's routing. This is called out explicitly in
CLI.md.src/gateway/reuses nothing fromcorein this PR (not even thequeue/bus modules the issue anticipated) — the fleet queue is Gateway fleet queue, routing, lease forwarding, fleet-wide one-lease rule #118's; the
boundary test's allow-list is written narrowly on purpose so a later PR
widening it has to say so explicitly rather than the boundary silently
growing.
Rebase note (for reviewers of the diff)
The rebase carried real conflicts in
src/core/config.ts,src/daemon/ dispatcher.ts/dispatch.ts,src/contract/operations.ts/protocol.ts,src/cli/index.ts, and the docs — both branches touchedstatus.get,mode, config keys, and the protocol version. Beyond the marked conflicts,a handful of files needed silent-merge fixups the diff will show as small,
separate changes on top of the original commits: a few
testConfigfixtures had ended up with a duplicated
mode: "worker"key (harmlessduplicate object keys, but
tsccorrectly rejects them), andsrc/gateway/ aggregate.ts,worker-link.ts,test-support.ts, and two.test.tsfilesstill referenced the pre-#116 top-level
mode/healthshape — expected,since the branch's own last commit (
3d53b21, "mode is a field ofstatus.get's daemon block") says outright that "the code follows on the
rebase onto that move." That follow-up is folded into that same commit here
rather than left as a separate "fix the rebase" commit.
Test inventory
src/gateway/worker-registry.test.ts,service.test.ts,dispatcher.test.ts,aggregate.test.ts,boundary.test.ts— the newmodule's own unit coverage (connect/disconnect/incompatible/drain
transitions, retention timing,
hellonegotiation, every gateway-onlyoperation's refusal, aggregation math, the import boundary).
src/ports/uplink.test.ts— the in-memory uplink fake against both portinterfaces.
src/daemon/gateway-uplink.test.ts— the worker-side dial/reconnect loop.src/core/config.test.ts— gateway config validation (HTTP-required,worker-only-key warnings,
gateway.urlscheme validation, moderesolution order).
src/contract/operations.test.ts—status.get/catalog.get/worker.*schema round trips, including the gateway shape from requirement above.
src/http/app.test.ts,src/cli/index.test.ts—/v1/workersroutes andsimlock worker/simlock statusrendering.e2e/gateway-fleet.test.ts(new) — two fake-driver workers join agateway;
simlock statusagainst the gateway shows both with capacity,leases, and catalog; killing one flips it to
disconnectedwithoutpolling; drain/undrain round-trips. This is the issue's "Done when"
scenario.
Validation run on the final head (
7470820)pnpm run typecheck— cleanpnpm run typecheck:e2e— cleanpnpm run lint— cleanpnpm run format:check— cleanpnpm run test(unit) — 90 files, 1622 passed, 2 skippedpnpm run test:e2e(fast tag) — 17 files, 56 passed, 1 expected fail, 9skipped
pnpm exec fallow audit --base origin/claude/adr-0005-116-device-exec—clean (72 changed files)
Deviations from the ADR
None found in this PR's scope (requirements 1–9, 20–25, 31–33). Requirements
outside that range (fleet queue/routing, failure paths, durable retry) are
explicitly out of scope here and land with #118/#119.
Part of #115
Closes #117